<?php
/**
 * Celebrio FileSystem module
 *
 * @copyright  Copyright (c) 2011 Celebrio Software
 * @package    Celebrio
 * @subpackage FileSystem
 */
namespace Celebrio\FileSystem;

use Nette\Object;
use Nette\Environment;

use dibi;
use DibiRow;
use InvalidArgumentException;

use Celebrio\Core\ServiceProvider;

use Celebrio\FileSystem\PermissionChecker;
use Celebrio\FileSystem\FileNameCollisionException;
use Celebrio\FileSystem\AccessDeniedException;
use Celebrio\FileSystem\InvalidFilesystemOperation;

use Celebrio\Diagnostics\Timer;
use Celebrio\Diagnostics\TimerLevel;

/**
 * VFS Engine. It does all the work with database which is need to
 * FS to run correctly. This class does NOT work with real files
 * on the FS storage (disk, gdocs, azure, picasa...). The class
 * assumes the appropriate database tables have been created
 * (those in FS_xxx.sql script).
 *
 * VfsEngine also does NOT affect setting permissions or informing
 * about them, there are special classes to do it (see Permission
 * package).
 *
 * VfsEngine should be called ONLY via VirtualFileSystem.
 *
 * @author pavel
 */
class VfsEngine extends Object {

    private $user;
    private $permissionChecker = null;

    //private $roles = "roles";

    private $fpermissions;
    private $fprivileges;
    private $files;
    private $mimetypes;

    private $deleting = array();

    public function __construct($user) {
        $this->fpermissions = "fpermissions";
        $this->fprivileges = "fprivileges";
        $this->files = "files";
        $this->mimetypes = "mimetypes";
        $this->user = $user;
    }

    public function getPermissionChecker() {
        Timer::start("VFSEng - checkPermissions", TimerLevel::DETAILED);
        if ($this->permissionChecker == null) {
            $this->permissionChecker = new PermissionChecker($this->user);
        }
        Timer::stop("VFSEng - checkPermissions");
        return $this->permissionChecker;
    }

    private function getUserId() {
        return $this->user->getIdentity()->data["id"];
    }

    public function getDeleted() {
        return $this->deleting;
    }

    public function resetDeleted() {
        $this->deleting = array();
    }


    /**
     * Method save() works only with the database records, it does not
     * saves any files physicaly to the disk or azure storage. It checks
     * whether the file exists and if yes, calls fileWritten() method.
     * If the file does not exist, the save() method is called recursively
     * and attempts to create all missing parent directories, according
     * to the URL of the given file. At the top, it should always encounter
     * the root/ directory and get back to the file (and finally create it).
     *
     * When the method finds the last existing entry in the path (it's the
     * file itself, if exists), it checks the permissions calling
     * PermissionChecker::checkWrite(). If the access (write permission)
     * is denied, the method throws an exception.
     *
     * If $inheritParentMimetype is set as TRUE, the newly created file
     * obtains the same mimetype as his parent. The directories "on the path"
     * are created like this.
     *
     * @param VirtualFile $file
     *
     * @todo uncomment the Exception throwing
     * @throws AccessDeniedException when the user does not have permission to
     *      perform the operation.
     */
    public function save(VirtualFile $file, $inheritParent = false, $rewrite = true) {
        Timer::start("VFSEng - saving ".$file->getUrl().$file->getName(), TimerLevel::DETAILED);
        if (!$this->fileExists($file)) {
            $parent = $this->getParent($file);

            $this->save($parent, true);
            //all parents exist by now, recursion ftw

            if (!$parent->isDirectory()) {
            	//Timer::stop("VFSEng - saving ".$file->getUrl().$file->getName());
                throw new InvalidFilesystemOperation("Saving file or directory into (under) file is not allowed.");
            }

            if ($inheritParent) {
                $file->setFstorageId($parent->getFstorageId());
                $file->setMimetypeId($parent->getMimetypeId());
            }
            $this->createInTheDirectory($parent, $file);
            $this->getPermissionChecker()->addResource($file);
            //TODO create access rights for the owner and add them to the permission checker list
        } else if ($rewrite) {
            if (!$this->getPermissionChecker()->checkWrite($file)) {
                //TODO uncomment when finished assigning permissions
                //throw new AccessDeniedException("access denied while writing to " . $file->getName() . $file->getUrl());
            }

            $requiredMimetypeId = $file->getMimetypeId();
            $requiredFstorageId = $file->getFstorageId();

            $this->completeFile($file);

            if ($requiredFstorageId != null) {
                $newValues = array("id_files_storage" => $requiredFstorageId);
            } else {
                $newValues = array("id_files_storage" => $file->getFstorageId());
            }

            if (!$file->isDirectory()) {
                $newValues["id_mimetype"] = $requiredMimetypeId;
            } else {
                $newValues["id_mimetype"] = $file->getMimetypeId();
                // handle saving file over existing directory
                //if ($requiredMimetypeId != 1)
                //throw new InvalidArgumentException("Saving file over directory.");
            }

            if (($newValues["id_files_storage"] != $file->getFstorageId())
                || ($newValues["id_mimetype"] != $file->getMimetypeId())
            ) {
                dibi::update($this->files, $newValues)
                        ->where("%and", array("name_file" => $file->getName(), "url" => $file->getUrl()))
                        ->execute();
            }

        } else {
        	Timer::stop("VFSEng - saving ".$file->getUrl().$file->getName());
            throw new FileNameCollisionException("Target file exists and replacing hasn't been required.");
        }
        $this->fileWritten($file);
        Timer::stop("VFSEng - saving ".$file->getUrl().$file->getName());
    }

    /**
     * Checks the permission for reading file and calls fileRead(), just to
     * let the logger know about accessing the file or whatever happens there.
     *
     * @param VirtualFile $file
     *
     * @todo uncomment the Exception throwing
     * @throws AccessDeniedException when the user does not have permission to
     *      perform the operation.
     */
    public function load(VirtualFile $file) {
        Timer::start("VFSEng - load-all", TimerLevel::DETAILED);
        //Timer::start("VFSEng - checkRead", TimerLevel::DETAILED);
        if (!$this->getPermissionChecker()->checkRead($file)) {
            //TODO uncomment when finished assigning permissions
            //throw new AccessDeniedException("access denied while reading from " . $file->getName() . $file->getUrl());
        }
        //Timer::stop("VFSEng - checkRead");
        Timer::start("VFSEng - complete file", TimerLevel::DETAILED);
        $file = $this->completeFile($file);
        Timer::stop("VFSEng - complete file");
		Timer::start("VFSEng - is directory", TimerLevel::DETAILED);
        $children = null;
        if ($file->isDirectory()) {
            $children = $this->getChildEntries($file);
        }
        Timer::stop("VFSEng - is directory");
		/*Timer::start("VFSEng - file read", TimerLevel::DETAILED);
        $this->fileRead($file);
        Timer::stop("VFSEng - file read");*/
        Timer::stop("VFSEng - load-all");
        return $children;
    }

    /**
     * Fills all the attributes of given VirtualFile according to the
     * appropriate database record. When the file does not exist (i.e it's
     * not present in the database, exception is thrown).
     *
     * @param VirtualFile $file The file (reference) we need to know info about
     *
     * @throws FileNotFoundException when the file is not present in DB
     */
    public function getFileInfo(VirtualFile $file) {
        $this->completeFile($file);
    }

    /**
     * Moves the source file (first parameter) to the path and name specified
     * by the second parameter (target). Beware, when trying to move file
     * /home/pavel/file.txt into directory /usr/pavel/, the second parameter
     * MUST specify the new filename, so it should look like this (root prefix
     * in the URL is VirtualFile convention):
     *      VirtualFile [ "url" => "root/usr/pavel/", "name" => "file.txt" ]
     *
     * The method move works also as the rename method would, to be honest,
     * we tried to get as close as possible to the basic behaviour of unix
     * "mv" command. It behaves like "mv $source $target" or "mv -f $source $target"
     * when $rewrite is set to TRUE.
     *
     * The third parameter (boolean) specifies whether we want to rewrite
     * existing target file (in case it already exists). Default value is false
     * due to security issues.
     *
     * @param VirtualFile $source The file we want to move (source path).
     * @param VirtualFile $target The target path where the file will be moved.
     * @param boolean $rewrite TRUE when we want to rewrite the already existing
     *      file on the "target" path, FALSE otherwise (FALSE is default value)
     *
     * @throws AccessDeniedException when the user does not have permission to
     *      perform the operation.
     * @throws FileNameCollisionException when the target file or directory
     *      already exists and rewriting has not been required.
     */
    public function move(VirtualFile $source, VirtualFile $target, $rewrite = false) {
        Timer::start("VFSEng - move-all", TimerLevel::DETAILED);
        if (!$this->getPermissionChecker()->checkRead($source)) {
            //TODO uncomment when finished assigning permissions
            //throw new AccessDeniedException("access denied while reading from " . $file->getName() . $file->getUrl());
        }

        $deleteTarget = false;
        if ($this->fileExists($target)) {
            if (!$rewrite) {
                throw new FileNameCollisionException("Target file exists and replacing hasn't been required.");
            } else {
                if ($this->getPermissionChecker()->checkDelete($target)) {
                    $deleteTarget = true;
                } else {
                    //TODO uncomment when finished assigning permissions
                    //throw new AccessDeniedException("access denied while deleting old version of " . $to->getName() . $to->getUrl());
                }
            }
        }
        $this->completeFile($target); //TODO do we need this? check!

        $targetParent = $this->getParent($target);
        $this->save($targetParent, true); //checking permissions inside
        $this->completeFile($source);

        if ($deleteTarget === true) {
            $this->delete($target);
            //TODO otestovat
        }

        $fileUpdate = array(
            "url" => $target->getUrl(),
            "name_file" => $target->getName(),
            "id_parent" => $targetParent->getId(),
        );

        $up = dibi::update($this->files, $fileUpdate)->
                where("%and", array("id_file" => $source->getId()));
        //$up->test();
        $up->execute();

        if ($source->isDirectory()) {
            //uprav aji vsechny deticky, vnoucatka a dalsi potomstvo

            $db = ServiceProvider::getService("Celebrio-Database-IDatabase");
            $movedUrlWithName = $source->getUrl() . $source->getName() . "/";
            $targetUrlWithName = $target->getUrl() . $target->getName() . "/";
            $newUrlPattern = $db->dbfConcat(
                "'" . $targetUrlWithName . "'",
                $db->dbfSubstr("url", $db->dbfStrlen("'" . $movedUrlWithName . "'") . " + 1")
            );
            $subTreeUpdate = "UPDATE [" . $this->files . "]
                        SET [url]=" . $newUrlPattern . "
                        WHERE [url] LIKE '" . $movedUrlWithName . "%'";
            //dibi::test($subTreeUpdate);
            dibi::query($subTreeUpdate);
        }
        
        Timer::stop("VFSEng - move-all");


    }

    /**
     * Deletes the given VirtualFile $file from DB including all
     * child-entries. This method also checks the deleting rights
     * when trying to delete every file and also sets the
     * class attribute $this->deleted to the array of really deleted files.
     * The files you don't have permission to delete are skipped and
     * their parents as well (the path must be kept).
     *
     * @param VirtualFile $file the file or directory we want to delete
     * @param boolean $fileRecordCompleted this parameter specifies whether
     *      the VirtualFile given as first parameter has already assigned
     *      all attributes (so it's not necessary to load them from database
     *      again). It's here just to optimize, feel free to skip it (so that
     *      the default FALSE value is used).
     *
     * @throws AccessDeniedException when the user does not have permission to
     *      delete at least one of the items under the specified directory
     *      (or the directory/file itself).
     */
    public function delete(VirtualFile $file, $fileRecordCompleted = false) {
        if (!$fileRecordCompleted) {
            $this->completeFile($file);
        }

        if (!$this->getPermissionChecker()->checkDelete($file)) {
            //TODO uncomment when finished assigning permissions
            //throw new AccessDeniedException("access denied while deleting " . $file->getName() . $file->getUrl());
        }

        $allChildrenDeleted = true;
        $children = $this->getChildEntries($file);
        foreach ($children as $child) {
            try {
                $this->delete($child, true);
                //$deletedChildren[] = $child;
            } catch (AccessDeniedException $e) {
                $allChildrenDeleted = false;
            }
        }

        if ($allChildrenDeleted) {
            $this->deleteEntry($file);
            $this->fileDelete($file);
            $this->deleting[] = $file;
        } else {
            throw new AccessDeniedException("access denied while deleting children of " . $file->getName() . $file->getUrl());
        }

    }

    /**
     * Copies the specified items (array of files/dirs) to another location.
     * The items should be sorted in the way that there is no need to create
     * additional directories (to have complete path).
     *
     * @throws AccessDeniedException when the user does not have permission to
     *      perform the operation.
     */
    public function paste($filesToCopy, $rewrite) {
        Timer::start("VFSEng - paste", TimerLevel::DETAILED);
        foreach ($filesToCopy as $file) {
            $this->save($file, false, $rewrite);
        }
        Timer::stop("VFSEng - paste");
    }

    /**
     * Loads the entry and all subdirectories and files contained in the
     * tree below the $file parameter.
     *
     * @param VirtualFile $file The file or directory which is the root of
     *      the FS subtree we want to load.
     * @return array(VirtualFile) all the items in the required subtree.
     *
     * @throws AccessDeniedException when the user does not have permission to
     *      read the contents of the root directory of the subtree.
     */
    public function deepLoad(VirtualFile $file) {
        Timer::start("VFSEng - deepLoad-all", TimerLevel::DETAILED);
        if (!$this->getPermissionChecker()->checkRead($file)) {
            //TODO uncomment when finished assigning permissions
            //throw new AccessDeniedException("access denied while deleting " . $file->getName() . $file->getUrl());
        }

        $this->completeFile($file);
        $toLoad = array($file);

        for ($ii = 0; isset($toLoad[$ii]); $ii++) {
            //TODO uncomment when finished assigning permissions
            //if ($this->getPermissionChecker()->checkRead($toLoad[$ii])) {
            $this->fileRead($toLoad[$ii]);
            $children = $this->getChildEntries($toLoad[$ii]);
            $toLoad = array_merge($toLoad, $children);
            //}
        }
        Timer::stop("VFSEng - deepLoad-all");
        return $toLoad;

    }


/* --------------------------------------------------------------------------- */
/* --------------------------- PRIVATE METHODS ------------------------------- */
/* --------------------------------------------------------------------------- */
/* --------------------------------------------------------------------------- */


    /**
     * Returns the parent of the given entry (in form of VirtualFile). When the
     * file is root, exception is thrown (for now).
     *
     * @todo handle the behaviour for root parent
     *
     * @param VirtualFile $file the file we need to know the parent of.
     * @return VirtualFile the object representing the directory (file parent)
     *      in the filesystem
     */
    private function getParent(VirtualFile $file) {
        Timer::start("VFSEng - gettingParent", TimerLevel::DETAILED);
        if ($file->isRoot()) throw new InvalidArgumentException("trying to get parent of root.");
        $path = $file->pathAsArray();
        $parentPathAndUrl = $path[1];

        $parent = new VirtualFile($parentPathAndUrl["name"], $parentPathAndUrl["url"]);
        try {
            $parent = $this->completeFile($file);
            if (!$parent->isDirectory()) {
                throw new InvalidStateException("The parent already exists and it's not directory");
            }
        } catch (FileNotFoundException $e) {
            $parent->setFstorageId($file->getFstorageId());
            $parent->setMimetypeId(VirtualFile::createMimeType(DataFile::DIR));
        }
        Timer::stop("VFSEng - gettingParent");
        return $parent;
    }

    /**
     * Creates the new file $file in the directory $parent. The new file
     * MUST NOT exist and the parent MUST exist. Should be called from
     * $this->save() method.
     *
     * @param VirtualFile $parent
     * @param VirtualFile $file The reference is modified - ID is set.
     */
    private function createInTheDirectory(VirtualFile $parent, VirtualFile $file) {
        Timer::start("VFSEng - create dir", TimerLevel::DETAILED);
        //\Nette\Debug::dump($parent);
        //\Nette\Debug::dump($file);

        //$parent = $this->completeFile($parent); //should be already completed

        if ($file->getMimetypeId() === null) {
            throw new InvalidArgumentException("null mimetype given");
        }

        dibi::query("INSERT INTO [" . $this->files . "]
                ([id_user], [id_mimetype], [name_file], [url], [id_parent], [id_files_storage]) 
                VALUES (%iN, %iN, %s, %s, %iN, %iN)", $this->getUserId(), $file->getMimetypeId(),
                    $file->getName(), $file->getUrl(), $parent->getId(), $file->getFstorageId());
        $file->setId(dibi::getInsertId());
        Timer::stop("VFSEng - create dir");
    }

    /**
     * When any write operation is performed in the FS, this method is called
     * with the parameter having (at least) the URL and NAME of the writen file.
     *
     * @param VirtualFile $file
     */
    private function fileWritten(VirtualFile $file) {
        //        \Nette\Debug::dump("Writing to the file " . $file->getUrl() . $file->getName());
    }

    /**
     * When any read operation is performed in the FS, this method is called
     * with the parameter having (at least) the URL and NAME of the file which
     * has been read.
     *
     * @param VirtualFile $file
     */
    private function fileRead(VirtualFile $file) {
        //        \Nette\Debug::dump("Reading file " . $file->getUrl() . $file->getName());
    }

    /**
     * When any deleting operation is performed in the FS, this method is called
     * with the parameter having (at least) the URL and NAME of the file which
     * has been deleted. It's also calling when moving the files to the new location
     * since the files on the old path(s) are "deleted".
     *
     * @param VirtualFile $file
     */
    private function fileDelete(VirtualFile $file) {
        //        \Nette\Debug::dump("Deleting file " . $file->getUrl() . $file->getName());
    }

    /**
     * Checks whether the given VirtualFile exists in the DB.
     *
     * @param VirtualFile $file The file that we want to know if it exists
     * @return boolean TRUE when the file exists, FALSE otherwise
     */
    private function fileExists(VirtualFile $file) {
        Timer::start("VFSEng - fileExists", TimerLevel::DETAILED);
        $count = dibi::select("*")->from($this->files)
                ->where(array("name_file" => $file->getName(), "url" => $file->getUrl()))
                ->count();
        
        Timer::stop("VFSEng - fileExists");
        if ($count) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Fills all the attributes of given VirtualFile according to the
     * appropriate database record. When the file does not exist (i.e it's
     * not present in the database, exception is thrown).
     *
     * @param VirtualFile $file The file we need info about.
     *
     * @return VirtualFile New file with all attributes assigned according to the DB
     *
     * @throws FileNotFoundException when the required file does not exist
     *      (in the database)
     */
    private function completeFile(VirtualFile $file) {
        Timer::start("VFSEng - completeFile", TimerLevel::DETAILED);
        $existing = $this->getEntryByNameAndUrl($file->getName(), $file->getUrl());

        if ($existing) {
            Timer::stop("VFSEng - completeFile");
            return $this->dibiRowToVirtualFile($existing, $file);
        } else {
        	Timer::stop("VFSEng - completeFile");
        	throw new FileNotFoundException ("the file " . $file->getUrl() . $file->getName() . " does not exist.");
        }
    }

    /**
     * Converts the given DibiRow into VirtualFile with the same attributes
     * as that in the DibiRow.
     *
     * @param DibiRow $row
     * @param VirtualFile $file Optional parameter. If not-null value is given,
     *      the attributes are assigned to the passed argument instead of creating
     *      new VirtualFile object.
     * @return VirtualFile the object with assigned attributes from $row.
     */
    private function dibiRowToVirtualFile(DibiRow $row, VirtualFile $file = null) {
        if ($file === null) {
            $file = new VirtualFile($row->name_file, $row->url);
        }
        $file->setId($row->id_file);
        $file->setParent($row->id_parent);

        $file->setFstorageId($row->id_files_storage);
        $file->setMimetypeId($row->id_mimetype);

        $file->setOwnerId($row->id_user);
        $file->setLockedById($row->locked);

        $file->setHidden($row->hidden);
        $file->setProtected($row->protected);
        $file->setEncrypted($row->encrypted);
        $file->setDeleted($row->deleted);
        $file->setHash($row->hash);

        return $file;
    }

    /**
     * Selects the file row from the database and returns it (as dibiRow).
     * The row is identified by the given parameters $name and $url.
     * The null URL identifies the root.
     *
     * @param String $name
     * @param String $url
     * @return mixed(DibiRow/false)
     */
    private function getEntryByNameAndUrl($name, $url) {
        
        Timer::start("VFSEng - getEntryByNameUrl", TimerLevel::DETAILED);
        $entryRow = dibi::select("*")->from($this->files)->
                where("%and", array("url" => $url, "name_file" => $name))->
                fetch();

        //\Nette\Debug::dump($entryRow);
        Timer::stop("VFSEng - getEntryByNameUrl");
        return $entryRow;
    }

    /**
     * The method returns all children of (i.e the items just 1 level under the)
     * given file (it should be directory so that the method makes sense, but it's
     * not checked yet).
     *
     * @param VirtualFile $file The child entries of this directory will be returned
     * @return array(VirtualFile) The array of child entries (files and directories)
     *
     * @throws InvalidArgumentException when file with null ID is given as parameter
     */
    private function getChildEntries(VirtualFile $file) {
        Timer::start("VFSEng - getChildren", TimerLevel::DETAILED);
        if ($file->getId() == null) throw new InvalidArgumentException("File with null ID given.");
        $childRows = dibi::select("*")->from($this->files)->
                where("%and", array("id_parent" => $file->getId()))->
                orderBy("name_file")->
                fetchAll();

        $childEntries = array();
        foreach ($childRows as $row) {
            //TODO check permissions before assigning
            $childEntries[] = $this->dibiRowToVirtualFile($row);
        }
        Timer::stop("VFSEng - getChildren");

        return $childEntries;
    }

    private function deleteEntry(VirtualFile $file) {
        $deleteQuery = dibi::delete($this->files)
                ->where("%and", array("id_file" => $file->getId()));
        $deleteQuery->execute();
    }


}